[id].vue 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar && haveEditAccess"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. v-model:autocomplete="autocompleteState.cache"
  10. :data="editState.blockData"
  11. :data-changed="autocompleteState.dataChanged"
  12. :workflow="workflow"
  13. :editor="editor"
  14. @update="updateBlockData"
  15. @close="(editState.editing = false), (editState.blockData = {})"
  16. />
  17. <workflow-details-card
  18. v-else
  19. :workflow="workflow"
  20. @update="updateWorkflow"
  21. />
  22. </div>
  23. <div class="flex-1 relative overflow-auto">
  24. <div
  25. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  26. >
  27. <ui-card
  28. v-if="!haveEditAccess"
  29. padding="px-2 mr-4"
  30. class="flex items-center overflow-hidden"
  31. style="min-width: 150px; height: 48px"
  32. >
  33. <span class="inline-block">
  34. <ui-img
  35. v-if="workflow.icon.startsWith('http')"
  36. :src="workflow.icon"
  37. class="w-8 h-8"
  38. />
  39. <v-remixicon v-else :name="workflow.icon" size="26" />
  40. </span>
  41. <div class="ml-2 max-w-sm">
  42. <p
  43. :class="{ 'text-lg': !workflow.description }"
  44. class="font-semibold leading-tight text-overflow"
  45. >
  46. {{ workflow.name }}
  47. </p>
  48. <p
  49. :class="{ 'text-sm': workflow.description }"
  50. class="text-gray-600 leading-tight dark:text-gray-200 text-overflow"
  51. >
  52. {{ workflow.description }}
  53. </p>
  54. </div>
  55. </ui-card>
  56. <ui-tabs
  57. v-model="state.activeTab"
  58. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  59. >
  60. <button
  61. v-if="haveEditAccess"
  62. v-tooltip="
  63. `${t('workflow.toggleSidebar')} (${
  64. shortcut['editor:toggle-sidebar'].readable
  65. })`
  66. "
  67. style="margin-right: 6px"
  68. @click="toggleSidebar"
  69. >
  70. <v-remixicon
  71. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  72. />
  73. </button>
  74. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  75. <ui-tab value="logs" class="flex items-center">
  76. {{ t('common.log', 2) }}
  77. <span
  78. v-if="workflowStates.length > 0"
  79. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  80. style="min-width: 25px"
  81. >
  82. {{ workflowStates.length }}
  83. </span>
  84. </ui-tab>
  85. </ui-tabs>
  86. <ui-card v-if="isTeamWorkflow" padding="p-1 ml-4 pointer-events-auto">
  87. <ui-input
  88. v-tooltip="'Workflow URL'"
  89. prepend-icon="riLinkM"
  90. :model-value="`https://automa.site/teams/${teamId}/workflows/${workflow.id}`"
  91. readonly
  92. @click="$event.target.select()"
  93. />
  94. </ui-card>
  95. <div class="flex-grow pointer-events-none" />
  96. <editor-used-credentials v-if="editor" :editor="editor" />
  97. <editor-local-actions
  98. :editor="editor"
  99. :workflow="workflow"
  100. :is-data-changed="state.dataChanged"
  101. :is-team="isTeamWorkflow"
  102. :can-edit="haveEditAccess"
  103. @update="onActionUpdated"
  104. @permission="checkWorkflowPermission"
  105. @modal="(modalState.name = $event), (modalState.show = true)"
  106. />
  107. </div>
  108. <ui-tab-panels
  109. v-model="state.activeTab"
  110. class="overflow-hidden h-full w-full"
  111. @drop="onDropInEditor"
  112. @dragend="clearHighlightedElements"
  113. @dragover.prevent="onDragoverEditor"
  114. >
  115. <ui-tab-panel cache value="editor" class="w-full">
  116. <workflow-editor
  117. v-if="state.workflowConverted"
  118. :id="route.params.id"
  119. :data="workflow.drawflow"
  120. :disabled="isTeamWorkflow && !haveEditAccess"
  121. :class="{ 'animate-blocks': state.animateBlocks }"
  122. class="h-screen"
  123. @init="onEditorInit"
  124. @edit="initEditBlock"
  125. @update:node="state.dataChanged = true"
  126. @delete:node="state.dataChanged = true"
  127. >
  128. <template v-if="!isTeamWorkflow || haveEditAccess" #controls-append>
  129. <button
  130. v-tooltip="t('workflow.autoAlign.title')"
  131. class="control-button hoverable ml-2"
  132. @click="autoAlign"
  133. >
  134. <v-remixicon name="riMagicLine" />
  135. </button>
  136. <ui-card padding="p-0 ml-2 undo-redo">
  137. <button
  138. v-tooltip.group="
  139. `${t('workflow.undo')} (${getReadableShortcut('mod+z')})`
  140. "
  141. :disabled="!commandManager.state.value.canUndo"
  142. class="p-2 rounded-lg transition-colors"
  143. @click="executeCommand('undo')"
  144. >
  145. <v-remixicon name="riArrowGoBackLine" />
  146. </button>
  147. <button
  148. v-tooltip.group="
  149. `${t('workflow.redo')} (${getReadableShortcut(
  150. 'mod+shift+z'
  151. )})`
  152. "
  153. :disabled="!commandManager.state.value.canRedo"
  154. class="p-2 rounded-lg transition-colors"
  155. @click="executeCommand('redo')"
  156. >
  157. <v-remixicon name="riArrowGoForwardLine" />
  158. </button>
  159. </ui-card>
  160. </template>
  161. </workflow-editor>
  162. <editor-local-ctx-menu
  163. v-if="editor"
  164. :editor="editor"
  165. @copy="copySelectedElements"
  166. @paste="pasteCopiedElements"
  167. @duplicate="duplicateElements"
  168. />
  169. </ui-tab-panel>
  170. <ui-tab-panel value="logs" class="mt-24 container">
  171. <editor-logs
  172. :workflow-id="route.params.id"
  173. :workflow-states="workflowStates"
  174. />
  175. </ui-tab-panel>
  176. </ui-tab-panels>
  177. </div>
  178. </div>
  179. <ui-modal
  180. v-model="modalState.show"
  181. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  182. v-bind="activeWorkflowModal.attrs || {}"
  183. >
  184. <template v-if="activeWorkflowModal.title" #header>
  185. {{ activeWorkflowModal.title }}
  186. <a
  187. v-if="activeWorkflowModal.docs"
  188. :title="t('common.docs')"
  189. :href="activeWorkflowModal.docs"
  190. target="_blank"
  191. class="inline-block align-middle"
  192. >
  193. <v-remixicon name="riInformationLine" size="20" />
  194. </a>
  195. </template>
  196. <component
  197. :is="activeWorkflowModal.component"
  198. v-bind="{ workflow }"
  199. v-on="activeWorkflowModal?.events || {}"
  200. @update="updateWorkflow"
  201. @close="modalState.show = false"
  202. />
  203. </ui-modal>
  204. <shared-permissions-modal
  205. v-model="permissionState.showModal"
  206. :permissions="permissionState.items"
  207. @granted="registerTrigger"
  208. />
  209. </template>
  210. <script setup>
  211. import {
  212. watch,
  213. provide,
  214. reactive,
  215. computed,
  216. onMounted,
  217. shallowRef,
  218. onBeforeUnmount,
  219. } from 'vue';
  220. import cloneDeep from 'lodash.clonedeep';
  221. import { getNodesInside } from '@braks/vue-flow';
  222. import { useI18n } from 'vue-i18n';
  223. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  224. import { customAlphabet } from 'nanoid';
  225. import { useToast } from 'vue-toastification';
  226. import defu from 'defu';
  227. import dagre from 'dagre';
  228. import { useUserStore } from '@/stores/user';
  229. import { useWorkflowStore } from '@/stores/workflow';
  230. import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
  231. import {
  232. useShortcut,
  233. getShortcut,
  234. getReadableShortcut,
  235. } from '@/composable/shortcut';
  236. import { getWorkflowPermissions } from '@/utils/workflowData';
  237. import { tasks } from '@/utils/shared';
  238. import { fetchApi } from '@/utils/api';
  239. import { useGroupTooltip } from '@/composable/groupTooltip';
  240. import { useCommandManager } from '@/composable/commandManager';
  241. import { debounce, parseJSON, throttle } from '@/utils/helper';
  242. import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
  243. import browser from 'webextension-polyfill';
  244. import dbStorage from '@/db/storage';
  245. import DroppedNode from '@/utils/editor/DroppedNode';
  246. import EditorCommands from '@/utils/editor/EditorCommands';
  247. import convertWorkflowData from '@/utils/convertWorkflowData';
  248. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  249. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  250. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  251. import WorkflowShareTeam from '@/components/newtab/workflow/WorkflowShareTeam.vue';
  252. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  253. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  254. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  255. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  256. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  257. import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
  258. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  259. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  260. import EditorUsedCredentials from '@/components/newtab/workflow/editor/EditorUsedCredentials.vue';
  261. let editorCommands = null;
  262. const executeCommandTimeout = null;
  263. const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 7);
  264. useGroupTooltip();
  265. const { t } = useI18n();
  266. const toast = useToast();
  267. const route = useRoute();
  268. const router = useRouter();
  269. const userStore = useUserStore();
  270. const workflowStore = useWorkflowStore();
  271. const commandManager = useCommandManager();
  272. const teamWorkflowStore = useTeamWorkflowStore();
  273. const { teamId, id: workflowId } = route.params;
  274. const isTeamWorkflow = route.name === 'team-workflows';
  275. const editor = shallowRef(null);
  276. const connectedTable = shallowRef(null);
  277. const state = reactive({
  278. showSidebar: true,
  279. dataChanged: false,
  280. animateBlocks: false,
  281. isExecuteCommand: false,
  282. workflowConverted: false,
  283. activeTab: route.query.tab || 'editor',
  284. });
  285. const permissionState = reactive({
  286. permissions: [],
  287. showModal: false,
  288. });
  289. const modalState = reactive({
  290. name: '',
  291. show: false,
  292. });
  293. const editState = reactive({
  294. blockData: {},
  295. editing: false,
  296. });
  297. const autocompleteState = reactive({
  298. cache: new Map(),
  299. dataChanged: false,
  300. });
  301. const workflowPayload = {
  302. data: {},
  303. isUpdating: false,
  304. };
  305. const workflowModals = {
  306. table: {
  307. icon: 'riKey2Line',
  308. width: 'max-w-2xl',
  309. component: WorkflowDataTable,
  310. title: t('workflow.table.title'),
  311. docs: 'https://docs.automa.site/api-reference/table.html',
  312. events: {
  313. /* eslint-disable-next-line */
  314. connect: fetchConnectedTable,
  315. disconnect() {
  316. connectedTable.value = null;
  317. },
  318. },
  319. },
  320. 'workflow-share': {
  321. icon: 'riShareLine',
  322. component: WorkflowShare,
  323. attrs: {
  324. blur: true,
  325. persist: true,
  326. customContent: true,
  327. },
  328. events: {
  329. close() {
  330. modalState.show = false;
  331. modalState.name = '';
  332. },
  333. publish() {
  334. modalState.show = false;
  335. modalState.name = '';
  336. },
  337. },
  338. },
  339. 'workflow-share-team': {
  340. icon: 'riShareLine',
  341. component: WorkflowShareTeam,
  342. attrs: {
  343. blur: true,
  344. persist: true,
  345. customContent: true,
  346. },
  347. events: {
  348. close() {
  349. modalState.show = false;
  350. modalState.name = '';
  351. },
  352. publish() {
  353. modalState.show = false;
  354. modalState.name = '';
  355. },
  356. },
  357. },
  358. 'global-data': {
  359. width: 'max-w-2xl',
  360. icon: 'riDatabase2Line',
  361. component: WorkflowGlobalData,
  362. title: t('common.globalData'),
  363. docs: 'https://docs.automa.site/api-reference/global-data.html',
  364. },
  365. settings: {
  366. width: 'max-w-2xl',
  367. icon: 'riSettings3Line',
  368. component: WorkflowSettings,
  369. title: t('common.settings'),
  370. attrs: {
  371. customContent: true,
  372. },
  373. events: {
  374. close() {
  375. modalState.show = false;
  376. modalState.name = '';
  377. },
  378. },
  379. },
  380. };
  381. const haveEditAccess = computed(() => {
  382. if (!isTeamWorkflow) return true;
  383. return userStore.validateTeamAccess(teamId, ['edit', 'owner', 'create']);
  384. });
  385. const workflow = computed(() => {
  386. if (isTeamWorkflow) {
  387. return teamWorkflowStore.getById(teamId, workflowId);
  388. }
  389. return workflowStore.getById(workflowId);
  390. });
  391. const workflowStates = computed(() =>
  392. workflowStore.getWorkflowStates(route.params.id)
  393. );
  394. const activeWorkflowModal = computed(
  395. () => workflowModals[modalState.name] || {}
  396. );
  397. const workflowColumns = computed(() => {
  398. if (connectedTable.value) {
  399. return connectedTable.value.columns;
  400. }
  401. return workflow.value.table;
  402. });
  403. provide('workflow', {
  404. editState,
  405. data: workflow,
  406. columns: workflowColumns,
  407. });
  408. provide('workflow-editor', editor);
  409. const updateBlockData = debounce((data) => {
  410. if (!haveEditAccess.value) return;
  411. const node = editor.value.getNode.value(editState.blockData.blockId);
  412. const dataCopy = JSON.parse(JSON.stringify(data));
  413. if (editState.blockData.itemId) {
  414. const itemIndex = node.data.blocks.findIndex(
  415. ({ itemId }) => itemId === editState.blockData.itemId
  416. );
  417. if (itemIndex === -1) return;
  418. node.data.blocks[itemIndex].data = dataCopy;
  419. } else {
  420. node.data = dataCopy;
  421. }
  422. editState.blockData.data = data;
  423. state.dataChanged = true;
  424. }, 250);
  425. const updateHostedWorkflow = throttle(async () => {
  426. if (isTeamWorkflow) return;
  427. if (!userStore.user || workflowPayload.isUpdating) return;
  428. const isHosted = userStore.hostedWorkflows[route.params.id];
  429. const isBackup = userStore.backupIds.includes(route.params.id);
  430. const workflowExist = workflowStore.getById(route.params.id);
  431. if (
  432. (!isBackup && !isHosted) ||
  433. !workflowExist ||
  434. Object.keys(workflowPayload.data).length === 0
  435. )
  436. return;
  437. workflowPayload.isUpdating = true;
  438. const delKeys = [
  439. 'id',
  440. 'pass',
  441. 'logs',
  442. 'trigger',
  443. 'createdAt',
  444. 'isDisabled',
  445. 'isProtected',
  446. ];
  447. delKeys.forEach((key) => {
  448. delete workflowPayload.data[key];
  449. });
  450. try {
  451. if (typeof workflowPayload.data.drawflow === 'string') {
  452. workflowPayload.data.drawflow = parseJSON(
  453. workflowPayload.data.drawflow,
  454. workflowPayload.data.drawflow
  455. );
  456. }
  457. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  458. method: 'PUT',
  459. keepalive: true,
  460. body: JSON.stringify({
  461. workflow: workflowPayload.data,
  462. }),
  463. });
  464. if (!response.ok) throw new Error(response.message);
  465. if (isBackup) {
  466. const result = await response.json();
  467. if (result.updatedAt) {
  468. await browser.storage.local.set({ lastBackup: result.updatedAt });
  469. }
  470. }
  471. workflowPayload.data = {};
  472. workflowPayload.isUpdating = false;
  473. } catch (error) {
  474. console.error(error);
  475. workflowPayload.isUpdating = false;
  476. }
  477. }, 5000);
  478. const onEdgesChange = debounce((changes) => {
  479. // const edgeChanges = { added: [], removed: [] };
  480. changes.forEach(({ type }) => {
  481. // if (type === 'remove') {
  482. // edgeChanges.removed.push(id);
  483. // } else if (type === 'add') {
  484. // edgeChanges.added.push(item);
  485. // }
  486. if (state.dataChanged) return;
  487. state.dataChanged = type !== 'select';
  488. });
  489. // if (state.isExecuteCommand) return;
  490. // let command = null;
  491. // if (edgeChanges.added.length > 0) {
  492. // command = editorCommands.edgeAdded(edgeChanges.added);
  493. // } else if (edgeChanges.removed.length > 0) {
  494. // command = editorCommands.edgeRemoved(edgeChanges.removed);
  495. // }
  496. // if (command) commandManager.add(command);
  497. }, 250);
  498. function registerTrigger() {
  499. const triggerBlock = workflow.value.drawflow.nodes.find(
  500. (node) => node.label === 'trigger'
  501. );
  502. registerWorkflowTrigger(workflowId, triggerBlock);
  503. }
  504. function executeCommand(type) {
  505. state.isExecuteCommand = true;
  506. if (type === 'undo') {
  507. commandManager.undo();
  508. } else if (type === 'redo') {
  509. commandManager.redo();
  510. }
  511. clearTimeout(executeCommandTimeout);
  512. setTimeout(() => {
  513. state.isExecuteCommand = false;
  514. }, 500);
  515. }
  516. function onNodesChange(changes) {
  517. const nodeChanges = { added: [], removed: [] };
  518. changes.forEach(({ type, id, item }) => {
  519. if (type === 'remove') {
  520. if (editState.blockData.blockId === id) {
  521. editState.editing = false;
  522. editState.blockData = {};
  523. }
  524. state.dataChanged = true;
  525. nodeChanges.removed.push(id);
  526. } else if (type === 'add') {
  527. nodeChanges.added.push(item);
  528. }
  529. });
  530. if (state.isExecuteCommand) return;
  531. let command = null;
  532. if (nodeChanges.added.length > 0) {
  533. command = editorCommands.nodeAdded(nodeChanges.added);
  534. } else if (nodeChanges.removed.length > 0) {
  535. command = editorCommands.nodeRemoved(nodeChanges.removed);
  536. }
  537. if (command) {
  538. commandManager.add(command);
  539. }
  540. }
  541. function autoAlign() {
  542. state.animateBlocks = true;
  543. const graph = new dagre.graphlib.Graph();
  544. graph.setGraph({
  545. rankdir: 'LR',
  546. ranksep: 100,
  547. ranker: 'tight-tree',
  548. });
  549. graph._isMultigraph = true;
  550. graph.setDefaultEdgeLabel(() => ({}));
  551. editor.value.getNodes.value.forEach(
  552. ({ id, label, dimensions, parentNode }) => {
  553. if (label === 'blocks-group-2' || parentNode) return;
  554. graph.setNode(id, {
  555. label,
  556. width: dimensions.width,
  557. height: dimensions.height,
  558. });
  559. }
  560. );
  561. editor.value.getEdges.value.forEach(({ source, target, id }) => {
  562. graph.setEdge(source, target, { id });
  563. });
  564. dagre.layout(graph);
  565. const nodeChanges = [];
  566. graph.nodes().forEach((nodeId) => {
  567. const graphNode = graph.node(nodeId);
  568. if (!graphNode) return;
  569. const { x, y } = graphNode;
  570. if (editorCommands.state.nodes[nodeId]) {
  571. editorCommands.state.nodes[nodeId].position = { x, y };
  572. }
  573. nodeChanges.push({
  574. id: nodeId,
  575. type: 'position',
  576. dragging: false,
  577. position: { x, y },
  578. });
  579. });
  580. editor.value.applyNodeChanges(nodeChanges);
  581. editor.value.fitView();
  582. setTimeout(() => {
  583. state.dataChanged = true;
  584. state.animateBlocks = false;
  585. }, 500);
  586. }
  587. function toggleSidebar() {
  588. state.showSidebar = !state.showSidebar;
  589. localStorage.setItem('workflow:sidebar', state.showSidebar);
  590. }
  591. function initEditBlock(data) {
  592. const { editComponent, data: blockDefData } = tasks[data.id];
  593. const blockData = defu(data.data, blockDefData);
  594. editState.blockData = { ...data, editComponent, data: blockData };
  595. if (data.id === 'wait-connections') {
  596. const connections = editor.value.getEdges.value.reduce(
  597. (acc, { target, sourceNode, source }) => {
  598. if (target !== data.blockId) return acc;
  599. let name = t(`workflow.blocks.${sourceNode.label}.name`);
  600. const { description } = sourceNode.data;
  601. if (description) name += ` (${description})`;
  602. acc.push({
  603. name,
  604. id: source,
  605. });
  606. return acc;
  607. },
  608. []
  609. );
  610. editState.blockData.connections = connections;
  611. }
  612. editState.editing = true;
  613. }
  614. async function updateWorkflow(data) {
  615. try {
  616. if (isTeamWorkflow) {
  617. if (!haveEditAccess.value && !data.globalData) return;
  618. await teamWorkflowStore.update({
  619. data,
  620. teamId,
  621. id: workflowId,
  622. });
  623. } else {
  624. await workflowStore.update({
  625. data,
  626. id: route.params.id,
  627. });
  628. }
  629. workflowPayload.data = { ...workflowPayload.data, ...data };
  630. if (!isTeamWorkflow) await updateHostedWorkflow();
  631. } catch (error) {
  632. console.error(error);
  633. }
  634. }
  635. function onActionUpdated({ data, changedIndicator }) {
  636. state.dataChanged = changedIndicator;
  637. workflowPayload.data = { ...workflowPayload.data, ...data };
  638. updateHostedWorkflow();
  639. }
  640. function isNodesInGroup(nodes) {
  641. const groupNodes = editor.value.getNodes.value.filter(
  642. (node) => node.label === 'blocks-group-2'
  643. );
  644. const nodeInGroup = new Set();
  645. const filteredNodes = nodes.filter((node) => node.label !== 'blocks-group-2');
  646. groupNodes.forEach(({ computedPosition, dimensions, id }) => {
  647. const rect = { ...computedPosition, ...dimensions };
  648. const nodesInGroup = getNodesInside(filteredNodes, rect);
  649. nodesInGroup.forEach((node) => {
  650. state.dataChanged = true;
  651. if (node.parentNode === id) {
  652. nodeInGroup.add(node.id);
  653. return;
  654. }
  655. nodeInGroup.add(node.id);
  656. const currentNode = editor.value.getNode.value(node.id);
  657. currentNode.parentNode = id;
  658. currentNode.position.x -= 450;
  659. });
  660. });
  661. filteredNodes.forEach((node) => {
  662. if (nodeInGroup.has(node.id)) return;
  663. const currentNode = editor.value.getNode.value(node.id);
  664. if (!currentNode.parentNode) return;
  665. currentNode.parentNode = undefined;
  666. });
  667. }
  668. function onEditorInit(instance) {
  669. editor.value = instance;
  670. instance.onEdgesChange(onEdgesChange);
  671. instance.onNodesChange(onNodesChange);
  672. instance.onEdgeDoubleClick(({ edge }) => {
  673. instance.removeEdges([edge]);
  674. });
  675. // instance.onEdgeUpdateEnd(({ edge }) => {
  676. // editorCommands.state.edges[edge.id] = edge;
  677. // });
  678. instance.onNodeDragStop(({ nodes }) => {
  679. isNodesInGroup(nodes);
  680. nodes.forEach((node) => {
  681. editorCommands.state.nodes[node.id] = node;
  682. });
  683. });
  684. instance.removeSelectedNodes(
  685. instance.getSelectedNodes.value.map(({ id }) => id)
  686. );
  687. instance.removeSelectedEdges(
  688. instance.getSelectedEdges.value.map(({ id }) => id)
  689. );
  690. const convertToObj = (array) =>
  691. array.reduce((acc, item) => {
  692. acc[item.id] = item;
  693. return acc;
  694. }, {});
  695. setTimeout(() => {
  696. const commandInitState = {
  697. nodes: convertToObj(instance.getNodes.value),
  698. edges: convertToObj(instance.getEdges.value),
  699. };
  700. editorCommands = new EditorCommands(instance, commandInitState);
  701. }, 1000);
  702. const { blockId } = route.query;
  703. if (blockId) {
  704. const block = instance.getNode.value(blockId);
  705. if (!block) return;
  706. instance.addSelectedNodes([block]);
  707. setTimeout(() => {
  708. const editorContainer = document.querySelector('.vue-flow');
  709. const { height, width } = editorContainer.getBoundingClientRect();
  710. const { x, y } = block.position;
  711. instance.setTransform({
  712. y: -(y - height / 2),
  713. x: -(x - width / 2) - 200,
  714. zoom: 1,
  715. });
  716. }, 200);
  717. }
  718. }
  719. function clearHighlightedElements() {
  720. const elements = document.querySelectorAll(
  721. '.dropable-area__node, .dropable-area__handle'
  722. );
  723. elements.forEach((element) => {
  724. element.classList.remove('dropable-area__node');
  725. element.classList.remove('dropable-area__handle');
  726. });
  727. }
  728. function toggleHighlightElement({ target, elClass, classes }) {
  729. const targetEl = target.closest(elClass);
  730. if (targetEl) {
  731. targetEl.classList.add(classes);
  732. } else {
  733. const elements = document.querySelectorAll(`.${classes}`);
  734. elements.forEach((element) => {
  735. element.classList.remove(classes);
  736. });
  737. }
  738. }
  739. function onDragoverEditor({ target }) {
  740. toggleHighlightElement({
  741. target,
  742. elClass: '.vue-flow__handle.source',
  743. classes: 'dropable-area__handle',
  744. });
  745. if (!target.closest('.vue-flow__handle')) {
  746. toggleHighlightElement({
  747. target,
  748. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  749. classes: 'dropable-area__node',
  750. });
  751. }
  752. }
  753. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  754. const block = parseJSON(dataTransfer.getData('block'), null);
  755. if (!block) return;
  756. clearHighlightedElements();
  757. const nodeEl = DroppedNode.isNode(target);
  758. if (nodeEl) {
  759. DroppedNode.replaceNode(editor.value, { block, target: nodeEl });
  760. return;
  761. }
  762. const isTriggerExists =
  763. block.id === 'trigger' &&
  764. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  765. if (isTriggerExists) return;
  766. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  767. const nodeId = nanoid();
  768. const newNode = {
  769. position,
  770. label: block.id,
  771. data: block.data,
  772. type: block.component,
  773. id: block.id === 'blocks-group-2' ? `group-${nodeId}` : nodeId,
  774. };
  775. editor.value.addNodes([newNode]);
  776. const edgeEl = DroppedNode.isEdge(target);
  777. const handleEl = DroppedNode.isHandle(target);
  778. if (handleEl) {
  779. DroppedNode.appendNode(editor.value, {
  780. target: handleEl,
  781. nodeId: newNode.id,
  782. });
  783. } else if (edgeEl) {
  784. DroppedNode.insertBetweenNode(editor.value, {
  785. target: edgeEl,
  786. nodeId: newNode.id,
  787. outputs: block.outputs,
  788. });
  789. }
  790. if (block.fromGroup) {
  791. setTimeout(() => {
  792. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  793. blockEl?.setAttribute('group-item-id', block.itemId);
  794. }, 200);
  795. }
  796. state.dataChanged = true;
  797. }
  798. function copyElements(nodes, edges, initialPos) {
  799. const newIds = new Map();
  800. let firstNodePos = null;
  801. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  802. const newNodeId = nanoid();
  803. const nodePos = {
  804. z: position.z || 0,
  805. y: position.y + 50,
  806. x: position.x + 50,
  807. };
  808. newIds.set(id, newNodeId);
  809. if (initialPos) {
  810. if (index === 0) {
  811. firstNodePos = {
  812. x: nodePos.x,
  813. y: nodePos.y,
  814. };
  815. initialPos = editor.value.project({
  816. y: initialPos.clientY,
  817. x: initialPos.clientX - 360,
  818. });
  819. Object.assign(nodePos, initialPos);
  820. } else {
  821. const xDistance = nodePos.x - firstNodePos.x;
  822. const yDistance = nodePos.y - firstNodePos.y;
  823. nodePos.x = initialPos.x + xDistance;
  824. nodePos.y = initialPos.y + yDistance;
  825. }
  826. }
  827. const copyNode = cloneDeep({
  828. type,
  829. data,
  830. label,
  831. id: newNodeId,
  832. selected: true,
  833. position: nodePos,
  834. });
  835. copyNode.data = reactive(copyNode.data);
  836. return copyNode;
  837. });
  838. const newEdges = edges.reduce(
  839. (acc, { target, targetHandle, source, sourceHandle }) => {
  840. const targetId = newIds.get(target);
  841. const sourceId = newIds.get(source);
  842. if (!targetId || !sourceId) return acc;
  843. const copyEdge = cloneDeep({
  844. selected: true,
  845. target: targetId,
  846. source: sourceId,
  847. id: `edge-${nanoid()}`,
  848. targetHandle: targetHandle.replace(target, targetId),
  849. sourceHandle: sourceHandle.replace(source, sourceId),
  850. });
  851. acc.push(copyEdge);
  852. return acc;
  853. },
  854. []
  855. );
  856. return {
  857. nodes: newNodes,
  858. edges: newEdges,
  859. };
  860. }
  861. function duplicateElements({ nodes, edges }) {
  862. const selectedNodes = editor.value.getSelectedNodes.value;
  863. const selectedEdges = editor.value.getSelectedEdges.value;
  864. const { edges: newEdges, nodes: newNodes } = copyElements(
  865. nodes || selectedNodes,
  866. edges || selectedEdges
  867. );
  868. selectedNodes.forEach((node) => {
  869. node.selected = false;
  870. });
  871. selectedEdges.forEach((edge) => {
  872. edge.selected = false;
  873. });
  874. editor.value.addNodes(newNodes);
  875. editor.value.addEdges(newEdges);
  876. }
  877. function copySelectedElements(data = {}) {
  878. const nodes = data.nodes || editor.value.getSelectedNodes.value;
  879. const edges = data.edges || editor.value.getSelectedEdges.value;
  880. const clipboardData = JSON.stringify({
  881. name: 'automa-blocks',
  882. data: { nodes, edges },
  883. });
  884. navigator.clipboard.writeText(clipboardData).catch((error) => {
  885. console.error(error);
  886. });
  887. }
  888. async function pasteCopiedElements(position) {
  889. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  890. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  891. const permission = await browser.permissions.request({
  892. permissions: ['clipboardRead'],
  893. });
  894. if (!permission) {
  895. toast.error('Automa require clipboard permission to paste blocks');
  896. return;
  897. }
  898. try {
  899. const copiedText = await navigator.clipboard.readText();
  900. const blocks = parseJSON(copiedText);
  901. if (blocks && blocks.name === 'automa-blocks') {
  902. const { nodes, edges } = copyElements(
  903. blocks.data.nodes,
  904. blocks.data.edges,
  905. position
  906. );
  907. editor.value.addNodes(nodes);
  908. editor.value.addEdges(edges);
  909. return;
  910. }
  911. } catch (error) {
  912. console.error(error);
  913. }
  914. }
  915. function undoRedoCommand(type, { target }) {
  916. const els = ['INPUT', 'SELECT', 'TEXTAREA'];
  917. if (els.includes(target.tagName) || target.isContentEditable) return;
  918. executeCommand(type);
  919. }
  920. function onKeydown({ ctrlKey, metaKey, shiftKey, key, target }) {
  921. const els = ['INPUT', 'SELECT', 'TEXTAREA'];
  922. if (els.includes(target.tagName) || target.isContentEditable) return;
  923. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  924. if (command('c')) {
  925. copySelectedElements();
  926. } else if (command('v')) {
  927. pasteCopiedElements();
  928. } else if (command('z')) {
  929. undoRedoCommand(shiftKey ? 'redo' : 'undo');
  930. }
  931. }
  932. async function fetchConnectedTable() {
  933. const table = await dbStorage.tablesItems
  934. .where('id')
  935. .equals(workflow.value.connectedTable)
  936. .first();
  937. if (!table) return;
  938. connectedTable.value = table;
  939. }
  940. function checkWorkflowPermission() {
  941. getWorkflowPermissions(workflow.value.drawflow).then((permissions) => {
  942. if (permissions.length === 0) return;
  943. permissionState.items = permissions;
  944. permissionState.showModal = true;
  945. });
  946. }
  947. function checkWorkflowUpdate() {
  948. const updatedAt = encodeURIComponent(workflow.value.updatedAt);
  949. fetchApi(
  950. `/teams/${teamId}/workflows/${workflowId}/check-update?updatedAt=${updatedAt}`
  951. )
  952. .then((response) => response.json())
  953. .then((result) => {
  954. if (!result) return;
  955. updateWorkflow(result).then(() => {
  956. editor.value.setNodes(result.drawflow.nodes || []);
  957. editor.value.setEdges(result.drawflow.edges || []);
  958. editor.value.fitView();
  959. });
  960. })
  961. .catch((error) => {
  962. console.error(error);
  963. });
  964. }
  965. const shortcut = useShortcut([
  966. getShortcut('editor:toggle-sidebar', toggleSidebar),
  967. getShortcut('editor:duplicate-block', duplicateElements),
  968. ]);
  969. watch(
  970. () => state.activeTab,
  971. (value) => {
  972. router.replace({ ...route, query: { tab: value } });
  973. }
  974. );
  975. watch(
  976. () => route.params.id,
  977. (value, oldValue) => {
  978. if (route.name !== 'workflows-details') return;
  979. if (value && oldValue && value !== oldValue) {
  980. window.location.reload();
  981. }
  982. }
  983. );
  984. /* eslint-disable consistent-return */
  985. onBeforeRouteLeave(() => {
  986. updateHostedWorkflow();
  987. if (!state.dataChanged || !haveEditAccess.value) return;
  988. const confirm = window.confirm(t('message.notSaved'));
  989. if (!confirm) return false;
  990. });
  991. onMounted(() => {
  992. if (!workflow.value) {
  993. router.replace('/');
  994. return null;
  995. }
  996. state.showSidebar =
  997. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  998. const convertedData = convertWorkflowData(workflow.value);
  999. updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
  1000. state.workflowConverted = true;
  1001. });
  1002. if (route.query.permission || (isTeamWorkflow && !haveEditAccess.value))
  1003. checkWorkflowPermission();
  1004. if (isTeamWorkflow && !haveEditAccess.value && workflow.value.updatedAt) {
  1005. checkWorkflowUpdate();
  1006. }
  1007. if (workflow.value.connectedTable) {
  1008. fetchConnectedTable();
  1009. }
  1010. window.onbeforeunload = () => {
  1011. updateHostedWorkflow();
  1012. if (state.dataChanged && haveEditAccess.value) {
  1013. return t('message.notSaved');
  1014. }
  1015. };
  1016. window.addEventListener('keydown', onKeydown);
  1017. });
  1018. onBeforeUnmount(() => {
  1019. window.onbeforeunload = null;
  1020. window.removeEventListener('keydown', onKeydown);
  1021. });
  1022. </script>
  1023. <style>
  1024. .vue-flow,
  1025. .editor-tab {
  1026. width: 100%;
  1027. height: 100%;
  1028. }
  1029. .vue-flow__node {
  1030. @apply rounded-lg;
  1031. }
  1032. .dropable-area__node,
  1033. .dropable-area__handle {
  1034. @apply ring-4;
  1035. }
  1036. .animate-blocks {
  1037. .vue-flow__transformationpane,
  1038. .vue-flow__node {
  1039. transition: transform 300ms ease;
  1040. }
  1041. }
  1042. .undo-redo {
  1043. button:not(:disabled):hover {
  1044. @apply bg-box-transparent;
  1045. }
  1046. button:disabled {
  1047. @apply text-gray-500 dark:text-gray-400;
  1048. }
  1049. }
  1050. </style>